在原本笔记的基础上作出修改
2、商户查询缓存
2.1 什么是缓存?实战篇Redis[1]
视频教程地址实战篇哔哩哔哩_bilibili
在原本笔记的基础上作出修改
学习内容
使用redis共享session来实现
通过本章节,我们会理解缓存击穿,缓存穿透,缓存雪崩等问题,让小伙伴的对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容
通过本章节,我们可以学会Redis的计数器功能, 结合Lua完成高性能的redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列
我们利用Redis的GEOHash来完成对于地理坐标的操作
主要是使用Redis来完成统计功能
使用Redis的BitMap数据统计功能
基于Set集合的关注、取消关注,共同关注等等功能,这一块知识咱们之前就讲过,这次我们在项目中来使用一下
基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能
1、短信登录
1.1、导入黑马点评项目
1.1.1 、导入SQL
1.1.2、有关当前模型
手机或者app端发起请求,请求我们的nginx服务器,nginx基于七层模型走的事HTTP协议,可以实现基于Lua直接绕开tomcat访问redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游tomcat服务器,打散流量,我们都知道一台4核8G的tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过nginx的负载均衡分流后,利用集群支撑起整个项目,同时nginx在部署了前端项目后,更是可以做到动静分离,进一步降低tomcat服务的压力,这些功能都得靠nginx起作用,所以nginx是整个项目中重要的一环。
在tomcat支撑起并发流量后,我们如果让tomcat直接去访问Mysql,根据经验Mysql企业级服务器只要上点并发,一般是16或32 核心cpu,32 或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。
1.1.3、导入后端项目
在资料中提供了一个项目源码:
1.1.4、导入前端工程
1.1.5 运行前端项目
chrome 浏览器 F12打开开发者模式, 点击左上角切换成手机模式
能显示下图说明已经部署成功, 前后端已经启动
1.2 、基于Session实现登录流程
发送验证码:
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
短信验证码登录、注册:
用户将验证码和手机号进行输入,后台从==session==中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
校验登录状态:
用户在请求时候,会从==cookie==中携带者JsessionId到后台,后台通过==JsessionId==从==session==中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到==threadLocal==中,并且放行
1.3 、实现发送短信验证码功能
页面流程
具体代码如下
贴心小提示:
具体逻辑上文已经分析,我们仅仅只需要按照提示的逻辑写出代码即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Override public Result sendCode (String phone, HttpSession session) { if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误!" ); } String code = RandomUtil.randomNumbers(6 ); session.setAttribute("code" ,code); log.debug("发送短信验证码成功,验证码:{}" , code); return Result.ok(); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Override public Result login (LoginFormDTO loginForm, HttpSession session) { String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误!" ); } Object cacheCode = session.getAttribute("code" ); String code = loginForm.getCode(); if (cacheCode == null || !cacheCode.toString().equals(code)){ return Result.fail("验证码错误" ); } User user = query().eq("phone" , phone).one(); if (user == null ){ user = createUserWithPhone(phone); } session.setAttribute("user" ,user); return Result.ok(); }
1.4、实现登录拦截功能
温馨小贴士:tomcat的运行原理
当用户发起请求时,会访问我们像tomcat注册的端口,任何程序想要运行,都需要有一个线程对当前端口号进行监听,tomcat也不例外,当监听线程知道用户想要和tomcat连接连接时,那会由监听线程创建socket连接,socket都是成对出现的,用户通过socket像互相传递数据,当tomcat端的socket接受到数据后,此时监听线程会从tomcat的线程池中取出一个线程执行用户请求,在我们的服务部署到tomcat后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的controller,service,dao中,并且访问对应的DB,在用户执行完请求后,再统一返回,再找到tomcat端的socket,再将数据写回到用户端的socket,完成请求和响应
通过以上讲解,我们可以得知 每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用threadlocal来做到线程隔离,每个线程操作自己的一份数据
温馨小贴士:关于threadlocal
如果小伙伴们看过threadLocal的源码,你会发现在threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离
拦截器代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object user = session.getAttribute("user" ); if (user == null ){ response.setStatus(401 ); return false ; } UserHolder.saveUser((User)user); return true ; } }
让拦截器生效
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Configuration public class MvcConfig implements WebMvcConfigurer { @Resource private StringRedisTemplate stringRedisTemplate; @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor ()) .excludePathPatterns( "/shop/**" , "/voucher/**" , "/shop-type/**" , "/upload/**" , "/blog/hot" , "/user/code" , "/user/login" ).order(1 ); registry.addInterceptor(new RefreshTokenInterceptor (stringRedisTemplate)).addPathPatterns("/**" ).order(0 ); } }
1.5、隐藏用户敏感信息⭐
我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了
在登录方法处修改
1 2 session.setAttribute("user" , BeanUtils.copyProperties(user,UserDTO.class));
在拦截器处:
1 2 UserHolder.saveUser((UserDTO) user);
在UserHolder处:将user对象换成UserDTO
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class UserHolder { private static final ThreadLocal<UserDTO> tl = new ThreadLocal <>(); public static void saveUser (UserDTO user) { tl.set(user); } public static UserDTO getUser () { return tl.get(); } public static void removeUser () { tl.remove(); } }
⭐BeanUtils.copyProperties() 方法
BeanUtils它提供了对java反射 和自省API的包装。它里面还有很多工具类,这里我们介绍一下copyProperties。
这里其实是为了快速的实现用户脱敏功能, 避免一些敏感信息返回给前端。
BeanUtils.copyProperties(Object source, Class target);
该方法会返回一个 target类型的对象, 也就是我们copy过值之后的对象,
需要注意的是, copyProperties() 对操作的对象以及类有一些要求
target中的存在的属性,source中一定要有,但是source中可以有多余的属性;
target中与source中相同的属性都会被替换,不管是否有值;
Spring的BeanUtils的copyProperties方法需要对应的属性有getter和setter方法;
如果存在属性完全相同的内部类,但是不是同一个内部类,即分别属于各自的内部类,则spring会认为属性不同,不会copy;
spring和apache的copy属性的方法源和目的参数的位置正好相反,所以导包和调用的时候都要注意一下。
⚠️⚠️⚠️但是我在使用的时候遇到了问题, 原本没有使用这个方法我是直接通过构造器将User转换成UserDTO , 似乎是因为没有删除之前的构造器, 导致在属性copy的过程中, 出现了NullPointerExceptin , 再删除了构造器之后正常运行了
1 2 3 4 5 public UserDTO (User user) { this .id=user.getId(); this .nickName=user.getNickName(); this .icon=user.getIcon(); }
源码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 private static void copyProperties (Object source, Object target, @Nullable Class<?> editable, @Nullable String... ignoreProperties) throws BeansException { Assert.notNull(source, "Source must not be null" ); Assert.notNull(target, "Target must not be null" ); Class<?> actualEditable = target.getClass(); if (editable != null ) { if (!editable.isInstance(target)) { throw new IllegalArgumentException ("Target class [" + target.getClass().getName() + "] not assignable to Editable class [" + editable.getName() + "]" ); } actualEditable = editable; } PropertyDescriptor[] targetPds = getPropertyDescriptors(actualEditable); List<String> ignoreList = (ignoreProperties != null ? Arrays.asList(ignoreProperties) : null ); for (PropertyDescriptor targetPd : targetPds) { Method writeMethod = targetPd.getWriteMethod(); if (writeMethod != null && (ignoreList == null || !ignoreList.contains(targetPd.getName()))) { PropertyDescriptor sourcePd = getPropertyDescriptor(source.getClass(), targetPd.getName()); if (sourcePd != null ) { Method readMethod = sourcePd.getReadMethod(); if (readMethod != null && ClassUtils.isAssignable(writeMethod.getParameterTypes()[0 ], readMethod.getReturnType())) { try { if (!Modifier.isPublic(readMethod.getDeclaringClass().getModifiers())) { readMethod.setAccessible(true ); } Object value = readMethod.invoke(source); if (!Modifier.isPublic(writeMethod.getDeclaringClass().getModifiers())) { writeMethod.setAccessible(true ); } writeMethod.invoke(target, value); } catch (Throwable ex) { throw new FatalBeanException ( "Could not copy property '" + targetPd.getName() + "' from source to target" , ex); } } } } } }
1.6、session共享问题
核心思路分析:
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,
但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,
所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?
早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大 。
2、session拷贝数据时,可能会出现延迟
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了
1.7 Redis代替session的业务流程
1.7.1、设计key的结构
首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。
1.7.2、设计key的具体细节
所以我们可以使用String结构,就是一个简单的key,value键值对的方式,但是关于key的处理,
session他是每个用户都有自己的session,但是redis的key是共享的,咱们就不能使用code了
在设计这个key的时候,我们之前讲过需要满足两点
1、key要具有唯一性
2、key要方便携带
如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据 存储到redis中并且从页面中带过来毕竟不太合适,
因此我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了
1.7.3、整体访问流程
当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。
1.8 基于Redis实现短信登录
这里具体逻辑就不分析了,之前咱们已经重点分析过这个逻辑啦。
UserServiceImpl代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 @Override public Result login (LoginFormDTO loginForm, HttpSession session) { String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手机号格式错误!" ); } String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone); String code = loginForm.getCode(); if (cacheCode == null || !cacheCode.equals(code)) { return Result.fail("验证码错误" ); } User user = query().eq("phone" , phone).one(); if (user == null ) { user = createUserWithPhone(phone); } String token = UUID.randomUUID().toString(true ); UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap <>(), CopyOptions.create() .setIgnoreNullValue(true ) .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())); String tokenKey = LOGIN_USER_KEY + token; stringRedisTemplate.opsForHash().putAll(tokenKey, userMap); stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES); return Result.ok(token); }
1 cp /usr/local/bin/redis6/utils/redis_init_script /etc/init.d/redis
拦截器设置
值得一提的是, 由于Spring并不会为我们的拦截器自动配置Bean , 因此在LoginInterceptor中是无法使用自动装配注解的,
对于这个问题, 可以通过手动new 对象 , 然后通过构造器在使用拦截器的时候传入StringRedisTemplate ,
LoginInterceptor.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class LoginInterceptor implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public LoginInterceptor (StringRedisTemplate stringRedisTemplate) { this .stringRedisTemplate=stringRedisTemplate; } public LoginInterceptor () {} @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session=request.getSession(); Object user = session.getAttribute(UserConstant.USER_LOGIN_STATUS); if (user==null ){ response.setStatus(401 ); return false ; } UserHolder.saveUser((UserDTO) user); return true ; } @Override public void afterCompletion (HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
MvcConfig.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Configuration public class MvcConfig implements WebMvcConfigurer { @Resource StringRedisTemplate stringRedisTemplate; @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor (stringRedisTemplate)).excludePathPatterns( "/user/code" , "/user/login" , "/blog/hot" , "/shop/**" , "/upload/**" , "voucher/**" ); } }
⭐bug : UserDTO的java.math.Long 无法转换成 String
此时我们可以通过BeanUtil 的CopyOptions.create() 参数来设置拷贝的条件,
代码如下
1 2 3 4 5 6 UserDTO userDTO=BeanUtil.copyProperties(user,UserDTO.class); Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap <>(), CopyOptions.create(). setIgnoreNullValue(true ). setFieldValueEditor((fieldName, fieldValue) ->fieldValue.toString()));
保存成功
1.9 解决状态登录刷新问题
1.9.1 初始方案思路总结:
在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的
1.9.2 优化方案
既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。
1.9.3 代码
通过前端发送请求的请求头来获取token, 如果没有token直接拦截,然后再通过token从redis中获取数据, 如果数据为空就返回true (放行),
如果存在数据那么就把redis中存储的数据的时间刷新,
前面遇到不符合条件的放行是把这个请求交给下一个拦截器拦截, 当前的拦截器只负责刷新token的有效时间
RefreshTokenInterceptor
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 public class RefreshTokenInterceptor implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public RefreshTokenInterceptor (StringRedisTemplate stringRedisTemplate) { this .stringRedisTemplate = stringRedisTemplate; } @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("authorization" ); if (StrUtil.isBlank(token)) { return true ; } String key = LOGIN_USER_KEY + token; Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key); if (userMap.isEmpty()) { return true ; } UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO (), false ); UserHolder.saveUser(userDTO); stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES); return true ; } @Override public void afterCompletion (HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }
LoginInterceptor
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (UserHolder.getUser() == null ) { response.setStatus(401 ); return false ; } return true ; } }
前言 :什么是缓存?
就像自行车,越野车的避震器
举个例子:越野车,山地自行车,都拥有"避震器",防止 车体加速后因惯性,在酷似"U"字母的地形上飞跃,硬着陆导致的损害 ,像个弹簧一样;
同样,实际开发中,系统也需要"避震器",防止过高的数据访问猛冲系统,导致其操作线程无法及时处理信息而瘫痪;
这在实际开发中对企业讲,对产品口碑,用户评价都是致命的;所以企业非常重视缓存技术;
缓存(Cache),就是数据交换的 缓冲区 ,俗称的缓存就是缓冲区内的数据 ,一般从数据库中获取,存储于本地代码(例如:
1 2 3 4 5 例1 :Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap <>(); 本地用于高并发 例2 :static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等缓存 例3 :Static final Map<K,V> map = new HashMap (); 本地缓存
由于其被Static 修饰,所以随着类的加载而被加载到内存之中 ,作为本地缓存,由于其又被final 修饰,所以其引用(例3:map)和对象(例3:new HashMap())之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效;
2.1.1 为什么要使用缓存
一句话:因为速度快,好用
缓存数据存储于代码中 ,而代码运行在内存中,内存的读写性能远 高于磁盘,缓存可以大大降低用户访问并发量带来的 服务器读写压力
实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;
但是缓存也会增加代码复杂度和运营的成本:
2.1.2 如何使用缓存
实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用
浏览器缓存 :主要是存在于浏览器端的缓存
**应用层缓存:**可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存
**数据库缓存:**在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中
**CPU缓存:**当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存
2.2 添加商户缓存
在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库那肯定慢咯,所以我们需要增加缓存
1 2 3 4 5 @GetMapping("/{id}") public Result queryShopById (@PathVariable("id") Long id) { return shopService.queryById(id); }
2.2.1 缓存模型和思路
标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。
2.1.2 代码
代码思路:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入redis。
ShopController.java
1 2 3 4 5 6 7 8 9 @GetMapping("/{id}") public Result queryShopById (@PathVariable("id") Long id) { return shopService.queryById(id); }
ShopServiceImpl.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Override public Result queryById (Long id) { String key=RedisConstants.CACHE_SHOP_KEY+id; String shopJson=stringRedisTemplate.opsForValue().get(key+id); if (StrUtil.isNotBlank(shopJson)){ Shop shop= BeanUtil.toBean(shopJson,Shop.class); return Result.ok(shop); } Shop shop=getById(id); if (shop==null ){ return Result.fail("店铺不存在!" ); } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop)); return Result.ok(shop); }
测试结果 :
2.3 缓存更新策略
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。
**内存淘汰:**redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
**超时剔除:**当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
**主动更新:**我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题
2.3.1 数据库缓存不一致解决方案:
由于我们的缓存的数据源来自于数据库 ,而数据库的数据是会发生变化的 ,因此,如果当数据库中数据发生变化,而缓存却没有同步 ,此时就会有一致性问题存在 ,其后果是:
用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;怎么解决呢?有如下几种方案
Cache Aside Pattern 人工编码方式 :缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案
Read/Write Through Pattern : 由系统本身完成 ,数据库与缓存的问题交由系统本身去处理
Write Behind Caching Pattern :调用者只操作缓存 ,其他线程去异步处理数据库,实现最终一致
2.3.2 数据库和缓存不一致采用什么方案
综合考虑使用方案一,但是方案一调用者如何处理呢?这里有几个问题
操作缓存和数据库时有三个问题需要考虑:
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,
那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
删除缓存还是更新缓存 ?
更新缓存 :每次更新数据库都更新缓存,无效写操作较多
删除缓存 :更新数据库时让缓存失效,查询时再更新缓存
如何保证缓存与数据库的操作的同时成功或失败 ?
单体系统 ,将缓存与数据库操作放在一个事务
分布式系统 ,利用TCC等分布式事务方案
应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存 ,
如果选择第一种方案
在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。
先操作缓存还是先操作数据库?
先删除缓存,再操作数据库 => 高概率出问题
发生概率较高, 容易出现问题
先操作数据库,再删除缓存 => 低概率出问题
由于redis缓存的速度远高于数据库的写入速度, 因此第二种情况发生的概率较低
因此我们选择先操作数据库,再删除缓存
图片示例
2.4 实现商铺和缓存与数据库双写一致
核心思路如下:
修改ShopController中的业务逻辑,满足下面的需求:
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
**设置超时时间 30min **
1 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
根据id修改店铺时,先修改数据库,再删除缓存
修改重点代码1 :修改ShopServiceImpl 的queryById方法
设置redis缓存时添加过期时间
修改重点代码2
代码分析:通过之前的淘汰,我们确定了采用删除策略,来解决双写问题,当我们修改了数据之后,然后把缓存中的数据进行删除,查询时发现缓存中没有数据,则会从mysql中加载最新的数据,从而避免数据库和缓存不一致的问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Override @Transactional public Result updateShop (Shop shop) { Long id=shop.getId(); if (id==null ){ Result.fail("店铺id不能为空" ); } String key=RedisConstants.CACHE_SHOP_KEY+id; this .baseMapper.updateById(shop); stringRedisTemplate.delete(key); return Result.ok(); }
通过postman直接向后端发送请求
注意此时的update 是Put类型的请求, 如果发送的是 post 那么对应的就是saveShop的方法😭
修改数据库数据, 然后查看redis图形化客户端发现数据已被删除, 测试成功!
2.5[缓存穿透]问题的解决思路
缓存穿透 :缓存穿透 是指客户端请求的数据在缓存中和数据库中都不存在 ,这样缓存永远不会生效,这些请求都会打到数据库。
造成缓存穿透的基本原因有两个:
第一, 自身业务代码或者数据出现问题。
第二, 一些恶意攻击、 爬虫等造成大量空命中。
也就是这个请求直接就穿透了缓存, 直击数据库 , 那么由于 ==数据库能承载的并发不如redis那么高== ,
如果大量的请求同时去访问这种不存在的数据 , 这些请求就会都访问到数据库, 就容易导致出现问题
⭐常见的==解决方案==有两种:
就是直接过滤没有在缓存中的数据
**缓存空对象思路分析:**当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库 ,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高 ,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了
==布隆过滤==:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的 二进制数组 ,走哈希思想去判断当前这个要查询的这个数据是否存在,
**如果布隆过滤器判断存在,则放行,**这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,
在数据库中查询出来这个数据后,再将其放入到redis中,
假设布隆过滤器判断这个数据不存在,则直接返回
这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突
核心代码
1 2 3 4 5 6 7 8 9 10 shop=getById(id); if (shop==null ){ stringRedisTemplate.opsForValue().set(key,"" ,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES); return null ; }
完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public Shop queryWithPassThrough (Long id) { String key=RedisConstants.CACHE_SHOP_KEY+id; String shopJson=stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)){ Shop shop= BeanUtil.toBean(shopJson,Shop.class); return shop; } if (shopJson!=null ){ return null ; } Shop shop=getById(id); if (shop==null ){ stringRedisTemplate.opsForValue().set(key,"" ,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES); return null ; } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES); return shop; }
2.6 编码解决商品查询的[缓存穿透]问题: 通过缓存空对象解决
核心思路如下:
在原来的逻辑中,我们如果发现这个数据在mysql中不存在,直接就返回404了,这样是会存在缓存穿透 问题的
现在的逻辑中:如果这个数据不存在,我们不会返回404 ,还是会把这个数据写入到Redis中,并且将value设置为空,
当再次发起查询时,我们如果发现命中之后,判断这个value是否是null,
如果是null,则是之前写入的数据,证明是缓存穿透数据,
如果不是,则直接返回数据。
小总结:
缓存穿透产生的原因是什么 ?
用户请求的数据在缓存中和数据库中都不存在,==数据库的并发量较低== ,不断发起这样的请求,给数据库带来巨大压力
缓存穿透的解决方案有哪些 ?
缓存null值
布隆过滤
实现起来较为复杂, 并且由于底层实现是hash , 有可能会出现问题
增强id的复杂度,避免被猜测id规律
做好数据的基础格式校验
加强用户权限校验
做好热点参数的限流
2.7 [缓存雪崩]问题及解决思路
缓存雪崩是指在同一时段大量的缓存key同时失效 或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
缓存key同时失效 : 大量key 的TTL同时到期
可以给不同key的TTL 添加随机值解决
解决方案 :
给不同的Key的TTL添加随机值
利用Redis集群提高服务的可用性
给缓存业务添加降级限流策略
给业务添加多级缓存
2.8 [缓存击穿]问题及解决思路
缓存击穿问题也叫==热点Key==问题,就是一个被高并发访问并且缓存重建业务较复杂的key 突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,
又可以这个查询数据库的过程比较复杂, 执行较慢, 导致后面的线程都开始查询, 执行这个操作, 导致对数据库访问过多
其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候 ,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大
解决方案一、使用锁来解决 :
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能 ,
因为此时会让查询的性能从并行变成了串行 ,
我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,
假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,
线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
解决方案二、逻辑过期方案
道理类似于逻辑删除 => 其实就是==永不过期==
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间 ,假设我们不设置过期时间,其实就不会有缓存击穿的问题,
但是不设置过期时间,这样会导致数据占用内存 过多,我们可以采用逻辑过期方案 。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
进行对比
**互斥锁方案:**由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦
2.9 利用互斥锁解决[缓存击穿]问题
核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿
操作锁的代码:
核心思路就是利用redis的setnx方法来表示获取锁,该方法含义是redis中如果没有这个key,则插入成功,返回1,在stringRedisTemplate中返回true, 如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false,我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 private boolean tryLock (String key) { Boolean flag= stringRedisTemplate.opsForValue().setIfAbsent(key, "1" , RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } private void unLock (String key) { stringRedisTemplate.delete(key); }
操作代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 public Shop queryWithMutex (Long id) throws RuntimeException { String key=RedisConstants.CACHE_SHOP_KEY+id; String shopJson=stringRedisTemplate.opsForValue().get(key+id); if (StrUtil.isNotBlank(shopJson)){ Shop shop= BeanUtil.toBean(shopJson,Shop.class); return shop; } if (shopJson!=null ){ return null ; } String lockKey=RedisConstants.LOCK_SHOP_KEY+id; Shop shop=null ; try { boolean isLock=tryLock(lockKey); if (!isLock){ Thread.sleep(50 ); return queryWithMutex(id); } shop=getById(id); if (shop==null ){ stringRedisTemplate.opsForValue().set(key,"" ,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES); return null ; } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException (e); } finally { unLock(lockKey); } return shop; }
使用jMeter / Postman模拟高并发环境
Apache JMeter是100%纯JAVA桌面应用程序,被设计为用于测试客户端/服务端结构的软件(例如web应用程序)。它可以用来测试静态和动态资源的性能
创建collections
创建请求并设置请求参数及请求头
选中collections,
设置需要发送的请求
其中iterations 表示的是线程个数
delay表示中间停留的时间
点击run , 测试完成
对照响应速度以及控制台 可以知道测试代码基本无误
可以看到只进行了一次数据库查询
查看redis , 写入了空对象 => 缓存穿透解决
2.10 利用逻辑过期解决[缓存击穿]问题
需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
注意, 使用逻辑过期, 需要现在redis 中插入永久的数据
思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。
如果封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么你
步骤一、
新建一个实体类,我们采用第二个方案,这个方案,对原来代码没有侵入性。
1 2 3 4 5 @Data public class RedisData { private LocalDateTime expireTime; private Object data; }
步骤二、
在ShopServiceImpl 新增此方法,利用单元测试进行缓存预热
在测试类中
在测试类中提前在redis中写入数据, 尽量让时间过期的快一点, 方便后面读取到过期数据
注意, 逻辑过期, 实际在redis中保存的数据的TTL 为 -1 => ==永不过期==
然后我们修改MySQL中存储的数据
cheems狗肉馆 => AAA_cheems狗肉馆
编写代码
ShopServiceImpl
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10 );public Shop queryWithLogicalExpire ( Long id ) { String key = CACHE_SHOP_KEY + id; String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)) { return null ; } RedisData redisData = JSONUtil.toBean(json, RedisData.class); Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); LocalDateTime expireTime = redisData.getExpireTime(); if (expireTime.isAfter(LocalDateTime.now())) { return shop; } String lockKey = LOCK_SHOP_KEY + id; boolean isLock = tryLock(lockKey); if (isLock){ CACHE_REBUILD_EXECUTOR.submit( ()->{ try { this .saveShop2Redis(id,20L ); }catch (Exception e){ throw new RuntimeException (e); }finally { unlock(lockKey); } }); } return shop; }
Postman测试结果
可以看到 后端返回了 ==过期的数据==
实际的数据是AAA_cheems狗肉馆
2.11 封装Redis工具类
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓
存击穿问题
方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
将逻辑进行封装
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 package com.hmdp.utils;import cn.hutool.core.bean.BeanUtil;import cn.hutool.core.util.BooleanUtil;import cn.hutool.core.util.StrUtil;import cn.hutool.json.JSON;import cn.hutool.json.JSONObject;import cn.hutool.json.JSONUtil;import com.hmdp.dto.Result;import com.hmdp.entity.Shop;import lombok.extern.slf4j.Slf4j;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.stereotype.Component;import javax.annotation.Resource;import java.time.LocalDateTime;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;import java.util.concurrent.TimeUnit;import java.util.function.Function;import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;@Slf4j @Component public class CacheClient { public static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10 ); @Resource private StringRedisTemplate stringRedisTemplate; public CacheClient () {} public void set (String key, Object value, Long time, TimeUnit unit) { String jsonStr = JSONUtil.toJsonStr(value); stringRedisTemplate.opsForValue().set(key,jsonStr,time,unit); } public void setWithLogicalExpire (String key, Object value, Long time, TimeUnit unit) { RedisData redisData = new RedisData (); redisData.setData(value); redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time))); String jsonStr = JSONUtil.toJsonStr(redisData); stringRedisTemplate.opsForValue().set(key,jsonStr); } public <R,ID> R queryWithPassThrough (String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallBack, Long time, TimeUnit unit) { String key=keyPrefix+id; String json=stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { R r=JSONUtil.toBean(json, type); return r; } if (json!=null ){ return null ; } R r=dbFallBack.apply(id); if (r==null ){ stringRedisTemplate.opsForValue().set(key,"" ,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES); return null ; } this .set(key,JSONUtil.toJsonStr(r),time,unit); return r; } public <R,ID> R queryWithLogicalExpire (String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallBack, Long time, TimeUnit unit) { String key= keyPrefix+id; String json=stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)){ return null ; } RedisData redisData = JSONUtil.toBean(json, RedisData.class); JSONObject data = (JSONObject) redisData.getData(); R r = JSONUtil.toBean(data, type); LocalDateTime expireTime=redisData.getExpireTime(); if (expireTime.isAfter(LocalDateTime.now())){ return r; } String lockKey=LOCK_SHOP_KEY+id; boolean isLock=tryLock(lockKey); if (isLock){ CACHE_REBUILD_EXECUTOR.submit(()->{ try { R newR = dbFallBack.apply(id); this .setWithLogicalExpire(key,newR,time,unit); }catch (Exception e){ throw new RuntimeException (e); }finally { unLock(lockKey); } }); } return r; } public <R,ID> R queryWithMutex (String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallBack, Long time, TimeUnit unit) { String key= CACHE_SHOP_KEY+id; String json=stringRedisTemplate.opsForValue().get(key+id); if (StrUtil.isNotBlank(json)){ return JSONUtil.toBean(json,type); } if (json!=null ){ return null ; } String lockKey=LOCK_SHOP_KEY+id; R r=null ; try { boolean isLock=tryLock(lockKey); if (!isLock){ Thread.sleep(50 ); return queryWithMutex(keyPrefix, id,type,dbFallBack, time ,unit); } r=dbFallBack.apply(id); if (r==null ){ stringRedisTemplate.opsForValue().set(key,"" ,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES); return null ; } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(r),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException (e); } finally { unLock(lockKey); } return r; } private boolean tryLock (String key) { Boolean flag= stringRedisTemplate.opsForValue().setIfAbsent(key, "1" , RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } private void unLock (String key) { stringRedisTemplate.delete(key); } }
在ShopServiceImpl 中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Resource private CacheClient cacheClient; @Override public Result queryById (Long id) { Shop shop = cacheClient .queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this ::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES); if (shop == null ) { return Result.fail("店铺不存在!" ); } return Result.ok(shop); }